12.1 再论数据库关系

一对多关系是最常用的关系,要实现这种关系,要在“多”这一侧加一个外键,指向“一”这一侧联接的记录。
一对一关系是简化版的一对多关系,限制“多”这一侧最多只能有一个记录。

12.1.1 多对多关系

一对多关系、多对一关系和一对一关系至少都有一侧是单个实体,所以记录之间的联系通过外键实现,让外键指向这个实体。而多对多关系就不能这么多,因为例如你不能在学生表中加入一个指向课程的外键(因为一个学生可以选择多个课程,一个外键不够用)。因此需要添加一张关联表,使多对多关系分解成原表和关联表之间的两个一对多关系。如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
registrations = db.Table('registration',
db.Column('student_id', db.Integer, db.ForeignKey('students.id')),
db.Column('class_id', db.Integer, db.ForeignKey('classes_id')))
class Student(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String)
classes = db.relationship('Class',
secondary=registrations,
backref=db.backref('students', lazy='dynamic')
lazy='dynamic')
class Class(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String)

注意

  • 多对多关系仍使用定义一对多关系的db.relationship()方法进行定义,但在多对多关系中,必须把secondary参数设为关联表
  • 多对多关系可以在任何一侧定义,backref参数为处理好关系的另一侧。
  • 关联表registrations是一个简单的表,不是模型。SQLALchemy会自动接管这个表。

现在假设学生是s,课程是c,学生s选择课程c的代码为:

1
2
>>>s.classes.append(c)
>>>db.session.add(s)

学生s退选课程c的代码为:

1
2
>>>s.classes.remove(c)
>>>db.session.add(s)

12.1.2 自引用关系

多对多关系可用于实现用户之间的关注。但现在存在一个问题:在学生和课程的例子中,关联表联接的是两个明确的实体。而表示用户关注其他用户时,只用用户一个实体,没有第二个实体(另一侧不是其他表,而是在同一张表中)。

自引用关系:如果关系中的两侧都在同一个表中,那这个关系称为自引用关系。

12.1.3 高级多对多关系

使用12.1.2介绍的自引用关系可在数据库中表示用户之间的关注,但存在一个限制:使用多对多关系,通常需要存储所联两个实体之间的额外信息(如关注时间等),而这种额外信息只能存储在关联表中(因为如果存在实体表中,就会出现12.1.1中所说的外键不够用类似的情况)。
但是现在关联表完全由SQLALchemy接管(此时关联表只是一个简单表,不是模型)。因此我们可以把关联表定义为模型(提升关联表的地位),使其变成程序可访问的。

1. 在app/models.py中定义关联表模型:

1
2
3
4
5
6
7
class Follow(db.Models):
__tablename__ = 'follows'
# 关注他人的人id
follower_id = db.Column(db.Integer, db.ForeignKey('users.id'), primary_key=True)
# 被别人关注的人id
followed_id = db.Column(db.Integer, db.ForeignKey('users.id'), primary_key=True)
timestamp = db.Column(db.DateTime, default=dbatetime.utcnow)

2. 在app/models.py中定义两个一对多关系从而实现多对多关系(因为是自引用关系,所以关系两侧都在同一侧定义):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class User(UserMixin, db.Model):
# ...
# 已关注
followed = db.relationship('Follow', # 和前面的12.1.1不同,这里是关联表,而不是“另一侧实体”
foreign_keys=[Follow.follower_id], # 为消除外键间的歧义,必须明确指定用哪个外键,因为关系表中有两个外键
backref=db.backref('follower', lazy='joined'), # db.backref()参数并不是指定这两个关系之间的引用关系,而是回引Follow模型。follower是关注他人的人
lazy='dynamic',
cascade='all, delete-orphan')
# 关注user的人
followers = db.relationship('Follow',
foreign_keys=[Follow.followed_id],
backref=db.backref('followed', lazy='joined'), # followed是被别人关注的人
lazy='dynamic',
cascade='all, delete-orphan')

注意

  • followedfollowers关系都定义为单独的一对多关系(User对Follow)。
  • foreign_keys参数必须指定外键以消除外键间的歧义。
  • db.backref()第一个参数并不是指定这两个关系(User和Follow)之间的引用关系,而是回引Follow模型
  • db.backref()lazy参数设为joined,可以实现立即从联结查询中加载相关对象。如:某个用户关注了100个用户,调用user.followed.all()后会返回一个列表,其中包含100个Follow实例每个Follow实例的followerfollowed回引属性都指向相应的用户
  • cascade参数用于设置在父对象上执行的操作会对相关对象有什么影响。当设为'all, delete-orphan'时表示启用所有默认层叠选项,而且还有删除孤儿记录。(可理解为父对象干什么,相关的对象就干什么)

3. 在app/models.py中定义一些关注关系的辅助方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class User(UserMixin, db.Model):
# ...
# 实现关注他人功能
def follow(self, user):
if not slef.is_following(user):
f = Follow(follower=self, followed=user)
db.session.add(f)
# 实现取消关注功能
def unfollow(self, user):
f = self.followed.filter_by(followed_id=user.id).first()
if f:
db.session.delete(f) # 删除Follow实例
# 实现查询是否关注了他人的功能
def is_following(self, user):
return self.followed.filter_by(followed_id=user.id).first() is not None
# 实现查询是否被他人关注的功能
def is_followed_by(self, user):
return self.followers.filter_by(follower_id=user.id).first() is not None
  • follow()函数中无需设定timestamp字段,因为在第一步中已经为其设置了默认值。

12.2 在资料页中显示关注者

1. 在app/templates/user.html中添加在用户个人资料页上的关注信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# ...
<div class="profile-header">
<p>
{% if current_user.can(Permission.FOLLOW) and user != current_user %}
{% if not current_user.is_following(user) %}
<a href="{{ url_for('main.follow', username=user.username) }}" class="btn btn-primary">Follow</a>
{% else %}
<a href="{{ url_for('main.unfollow', username=user.username) }}" class="btn btn-default">Unfollow</a>
{% endif %}
{% endif %}
<a href="{{ url_for('main.followers', username=user.username) }}">Followers: <span class="badge">{{ user.followers.count() - 1 }}</span></a>
<a href="{{ url_for('main.followed_by', username=user.username) }}">Following: <span class="badge">{{ user.followed.count() - 1 }}</span></a>
{% if current_user.is_authenticated and user != current_user and user.is_following(current_user) %}
| <span class="label label-default">Follows you</span>
{% endif %}
</p>
# ...

2. 在app/main/views.py中定义关注按钮对应的路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from decorators import permission_required
# ...
@main.route('/follow/<username>')
@login_required
@permission_required(Permission.Follow)
def follow(username):
user = User.query.filter_by(username=username).first()
if user is None:
flash('Invalid user.')
return redirect(url_for('main.index'))
# 判断是否已经关注了user
if current_user.is_following(user):
flash('You are already following this user.')
return redirect(url_for('main.user', username=username))
# 调用User模型中的follow方法
current_user.follow(user)
flash('You are now following {}'.format(username))
return redirect(url_for('main.user', username=username))

3. 在app/main/views.py中定义用于展示关注了你的人(粉丝)路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# ...
@main.route('/followers/<username>')
def followers(username):
user = User.query.filter_by(username=username).first()
if user is None:
flash('Invalid user.')
return redirect(url_for('main.index'))
page = request.args.get('page', 1, type=int)
pagination = user.followers.pagination(page,
per_page=current_app.config['FLASKY_FOLLOWERS_PER_PAGE'],
error_out=False)
# 列表表达式,pagination.items返回的是所有Follow实例,通过item.follower获取对应的用户(User实例)
follows = [{'user': item.follower, 'timestamp': item.timestamp}
for item in pagination.items]
return render_template('followers.html', user=user, title='Followers of',
endpoint='main.followers', pagination=pagination,
follows=follows)

4. 在app/main/views.py中定义用于展示你关注了谁的路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# ...
@main.route('/followers/<username>')
def followers(username):
user = User.query.filter_by(username=username).first()
if user is None:
flash('Invalid user.')
return redirect(url_for('main.index'))
page = request.args.get('page', 1, type=int)
pagination = user.followed.pagination(page,
per_page=current_app.config['FLASKY_FOLLOWERS_PER_PAGE'],
error_out=False)
# 列表表达式,pagination.items返回的是所有Follow实例,通过item.followed获取对应的用户(User实例)
follows = [{'user': item.followed, 'timestamp': item.timestamp}
for item in pagination.items]
return render_template('followers.html', user=user, title='Followed by',
endpoint='main.followers', pagination=pagination,
follows=follows)
  • 这个路由跟第三步中定义的followers路由一样,唯一的区别是followed_by中第10行和第14行通过followed获取相关对象。
  • followersfollowed_by都使用同一个模板followers.html

5. 在app/templates/followers.html中添加显示已关注的人和粉丝的模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{% extends "base.html" %}
{% import "_macros.html" as macros %}
{% block title %}Flasky - {{ title }} {{ user.username }}{% endblock %}
{% block page_content %}
<div class="page-header">
<h1>{{ title }} {{ user.username }}</h1>
</div>
<table class="table table-hover followers">
<thead><tr><th>User</th><th>Since</th></tr></thead>
{% for follow in follows %}
<!--读取的是follows列表中的每一个字典,然后通过follow.user获取字典中'user'键对应的值-->
{% if follow.user != user %}
<tr>
<td>
<a href="{{ url_for('main.user', username=follow.user.username) }}">
<img class="img-rounded" src="{{ follow.user.gravatar(size=32) }}">
{{ follow.user.username }}
</a>
</td>
<td>{{ moment(follow.timestamp).format('L') }}</td>
</tr>
{% endif %}
{% endfor %}
</table>
<div class="pagination">
{{ macros.pagination_widget(pagination, endpoint, username=user.username) }}
</div>
{% endblock %}

注意for follow in follows获取的是follows列表中的每一个字典,然后通过follow.user来获取其对应的User实例(因为’user’键对应的值是第四步中的item.followed)。

12.3 使用数据库联结查询所关注用户的文章

1. 在app/models.py中使用联结查询获取user所关注的用户发布的文章:

1
2
3
4
5
6
7
8
9
10
# ...
class User(UserMixin, db.Model):
# ...
# 获取已关注的人发布的文章
@property
def followed_posts(self):
return Post.query.join(Follow, Follow.followed_id == Post.author_id)\ # 被关注的人id==作者id
.filter_by(Follower_id == self.id) # 关注别人的人id==self.id

注意followed_posts()方法定义为属性,因此调用时无需加()

12.4 在首页显示所关注用户发布的文章

1. 在app/main/views.py中定义用于显示所有博客文章或只显示所关注用户发布的文章的路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# ...
@main.route('/', methods=['GET', 'POST']
def index():
# ...
show_followed = False
if current_user.is_authenticated:
# 从请求的cookies中获取show_followed的值,并转换为布尔值
show_followed = bool(request.cookies.get('show_followed', ''))
if show_followed:
# 获取所关注用户发布的文章
query = current_user.followed_posts
else:
# 获取全部文章
query = Post.query
pagination = query.order_by(Post.timestamp.des()).paginate(page,
per_page=current_app.config['FLASKY_POSTS_PER_PAGE'],
error_out=False)
psots = pagination.itmes
return render_template('index.html', form=form, posts=posts,
show_followed=show_followed, pagination=pagination)
  • 决定显示所有博客文章,还是只显示所关注用户发布的文章,取决于存储在cookiesshow_followed字段的值,如果其值是非空字符串,即show_followed变量为True,则只显示所关注用户发布的文章。
  • cookierequest.cookies字典的形式存储在请求对象中。(具体如何实现请看第二步)

2. 在app/main/views.py中为请求对象添加show_followedcookies值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from flask import make_response
# ...
@main.route('/all')
@login_required
def show_all():
# 这个路由重定向到首页
resp = make_response(redirect(url_for('main.index')))
# 在resp中添加cookie,并设适当的值
resp.set_cookie('show_followed', '', max_age=30*24*60*60)
return resp
@main.route('/followed')
@login_required
def show_followed():
resp = make_response(redirect(url_for('main.index')))
resp.set_cookie('show_followed', '1', max_age=30*24*60*60)
return resp

注意set_cookie()函数的前两个参数分别是cookie名。可选参数max_age设置cookie的过期时间,单位为秒,如果不指定max_age参数,则浏览器关闭后cookie就会过期。

3. 在app/models.py中在创建新用户时将用户设为自己的关注者(即自己关注自己):

有时希望在查看所关注用户发布的文章中,也能看到自己的动态,此时就需要将自己设为自己的关注者(自己关注自己)

1
2
3
4
5
6
7
8
# ...
class User(UserMixin, db.Model):
# ...
def __init__(self, **kwargs):
# ...
self.follow(self)

4. 在app/models.py中将之前已经创建的用户设为自己的关注者:

创建函数来更新数据库,这一技术经常用来更新已部署的程序。

1
2
3
4
5
6
7
8
9
10
11
12
# ...
class User(UserMixin, db.Model):
# ...
@staticmethod
def add_self_follows():
for user in User.query.all():
if not user.is_following(user):
user.follow(user)
db.session.add(user)
db.session.commit()

最后,注意:因为将用户设为自己的关注者,因此在渲染模板时,已关注的人的数量和粉丝的数量需要减去1(即12.2中第一步中的第11行和第12行)。